--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 6877031cd6f56d6a1dcd688a55b317882844f6b9
Parents : cda6b00
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-16T06:37:21-05:00
feat: implement battery status monitoring across platforms with UI integration and localization support
Changes
25 files changed, 1018 insertions(+), 34 deletions(-)
Diff
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 54636575..28aa0400 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,23 +13,32 @@ All notable changes to this project will be documented in this file.
- Settings: Reticulum instance/share controls, tabbed Settings nav, desktop close/tray behavior
- Nomad favourites: per-identity section layout in the database
- Optional pip-rns / rngit install path for RNS packages and docs
+- Message export and import with contacts and read state
+- Host battery status on About and in the header (Electron, Android, Chromium)
+- RSM signing and verification for meshchatx.rsm (CI and pre-commit resign)
+- Notification sound settings
- LXMFy 1.6.5 vendor refresh, wasmtime, mutation test tasks
### Changed
- UI opens sooner: HTTP binds first, Reticulum starts in the background
+- Conversations load faster: slim list and thread queries via fields_meta and attachment flags
- Relay Chat: denser hub UI, announce interval, collapsed system lines, reconnect notices
+- Low-memory cleanup and SQLite pragmas under memory pressure
- CI benches use median-of-medians and quieter regression gates
+- Backend tests can run sharded in CI
- Plugin strings live in plugin bundles, not main locale files
### Fixed
-- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, emulator smoke
-- RNode BLE on desktop needs bleak ([#46](https://github.com/Quad4-Software/MeshChatX/issues/46)); startup disables unsupported interfaces
+- Android: lxmfy packaging, flock soft-lock, splash/logo clipping, emulator smoke, Landlock skipped on Android
+- Android RNode BLE/USB via Chaquopy
+- Startup check and disable unsupported interfaces
- Nomad favourites: no more Unknown Node / lost custom sections
-- Relay Chat message dedupe; network visualiser faster on large meshes
+- Relay Chat message dedupe. Network visualiser faster on large meshes
- Bots and RNSh work in frozen macOS/Windows builds (`--meshchatx-run-module`)
-- Sensitive config no longer mutable over WebSocket; Reticulum config repair on startup
+- Sensitive config no longer mutable over WebSocket. Reticulum config repair on startup
+- Paper message URI encoding for non-ASCII title and content
- Nightly releases and broader self-test / CI coverage
## [4.7.2] - 2026-07-06
diff --git a/README.md b/README.md
index 4be3e200..bab0337d 100644
--- a/README.md
+++ b/README.md
@@ -421,18 +421,18 @@ task build
`Makefile` targets are thin shims that delegate to `task` (same commands as above):
-| Command | Delegates to | Description |
-| -------------- | -------------- | --------------------------------------------- |
-| `make install` | `task install` | Install pnpm and UV dependencies |
-| `make run` | `task run` | Run MeshChatX via UV |
-| `make build` | `task build` | Build frontend and backend artifacts |
-| `make format` | `task format` | Format frontend and backend code |
-| `make lint` | `task lint` | ESLint, vue-tsc, knip, Ruff, and basedpyright |
-| `make test` | `task test` | Run frontend and backend tests |
-| `make clean` | `task clean` | Remove build artifacts and node_modules |
-| `make tree-rsm-verify` | (shell) | Verify `meshchatx.rsm` signature and hashes |
-| `make tree-rsm-sign` | (shell) | Sign tree inventory (requires `RNS_ID_PATH`) |
-| `make hooks-install` | (shell) | Enable tracked pre-commit RSM resign hook |
+| Command | Delegates to | Description |
+| ---------------------- | -------------- | --------------------------------------------- |
+| `make install` | `task install` | Install pnpm and UV dependencies |
+| `make run` | `task run` | Run MeshChatX via UV |
+| `make build` | `task build` | Build frontend and backend artifacts |
+| `make format` | `task format` | Format frontend and backend code |
+| `make lint` | `task lint` | ESLint, vue-tsc, knip, Ruff, and basedpyright |
+| `make test` | `task test` | Run frontend and backend tests |
+| `make clean` | `task clean` | Remove build artifacts and node_modules |
+| `make tree-rsm-verify` | (shell) | Verify `meshchatx.rsm` signature and hashes |
+| `make tree-rsm-sign` | (shell) | Sign tree inventory (requires `RNS_ID_PATH`) |
+| `make hooks-install` | (shell) | Enable tracked pre-commit RSM resign hook |
## Versioning
diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java
index a9426fe8..2f7015fa 100644
--- a/android/app/src/main/java/com/meshchatx/MainActivity.java
+++ b/android/app/src/main/java/com/meshchatx/MainActivity.java
@@ -13,12 +13,14 @@ import android.content.pm.PackageManager;
import android.content.res.Configuration;
import android.hardware.usb.UsbManager;
import android.net.Uri;
+import android.os.BatteryManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.PowerManager;
+import android.content.IntentFilter;
import android.provider.MediaStore;
import android.provider.Settings;
import android.webkit.CookieManager;
@@ -1205,6 +1207,59 @@ public class MainActivity extends AppCompatActivity {
return "android";
}
+ @JavascriptInterface
+ public String getBatteryStatus() {
+ try {
+ int level = -1;
+ boolean charging = false;
+ BatteryManager batteryManager =
+ (BatteryManager) activity.getSystemService(Context.BATTERY_SERVICE);
+ if (batteryManager != null) {
+ level = batteryManager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY);
+ // BatteryManager may return Integer.MIN_VALUE when unsupported.
+ if (level < 0 || level > 100) {
+ level = -1;
+ }
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ charging = batteryManager.isCharging();
+ }
+ }
+ IntentFilter filter = new IntentFilter(Intent.ACTION_BATTERY_CHANGED);
+ Intent batteryStatus = activity.registerReceiver(null, filter);
+ if (batteryStatus != null) {
+ if (level < 0) {
+ int rawLevel = batteryStatus.getIntExtra(BatteryManager.EXTRA_LEVEL, -1);
+ int scale = batteryStatus.getIntExtra(BatteryManager.EXTRA_SCALE, 100);
+ if (rawLevel >= 0 && scale > 0) {
+ level = Math.round((rawLevel * 100f) / scale);
+ }
+ }
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
+ int status =
+ batteryStatus.getIntExtra(
+ BatteryManager.EXTRA_STATUS,
+ BatteryManager.BATTERY_STATUS_UNKNOWN);
+ charging =
+ status == BatteryManager.BATTERY_STATUS_CHARGING
+ || status == BatteryManager.BATTERY_STATUS_FULL;
+ }
+ }
+ if (level < 0) {
+ return "";
+ }
+ if (level > 100) {
+ level = 100;
+ }
+ return "{\"level\":"
+ + level
+ + ",\"charging\":"
+ + (charging ? "true" : "false")
+ + ",\"source\":\"android\"}";
+ } catch (Exception e) {
+ return "";
+ }
+ }
+
@JavascriptInterface
public String getPreferredUiTheme() {
return activity.resolvePreferredUiTheme();
diff --git a/electron/main.js b/electron/main.js
index 8e22148c..ea18481e 100644
--- a/electron/main.js
+++ b/electron/main.js
@@ -9,6 +9,7 @@ const {
Menu,
Notification,
powerSaveBlocker,
+ powerMonitor,
session,
clipboard,
} = require("electron");
@@ -360,6 +361,55 @@ ipcMain.handle("get-memory-usage", async () => {
return process.getProcessMemoryInfo();
});
+ipcMain.handle("get-battery-status", async () => {
+ let onBattery = null;
+ try {
+ if (typeof powerMonitor?.isOnBatteryPower === "function") {
+ onBattery = Boolean(powerMonitor.isOnBatteryPower());
+ }
+ } catch {
+ onBattery = null;
+ }
+
+ let level = null;
+ // Linux sysfs: common laptop BAT0/BAT1 capacity files.
+ if (process.platform === "linux") {
+ try {
+ const powerSupplyDir = "/sys/class/power_supply";
+ if (fs.existsSync(powerSupplyDir)) {
+ const entries = fs.readdirSync(powerSupplyDir);
+ for (const name of entries) {
+ if (!/^BAT\d+$/i.test(name) && name.toUpperCase() !== "BATTERY") {
+ continue;
+ }
+ const capacityPath = path.join(powerSupplyDir, name, "capacity");
+ if (!fs.existsSync(capacityPath)) {
+ continue;
+ }
+ const raw = fs.readFileSync(capacityPath, "utf8").trim();
+ const parsed = Number.parseInt(raw, 10);
+ if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 100) {
+ level = parsed;
+ break;
+ }
+ }
+ }
+ } catch {
+ level = null;
+ }
+ }
+
+ if (level == null && onBattery == null) {
+ return null;
+ }
+ return {
+ level,
+ charging: onBattery == null ? null : !onBattery,
+ on_battery: onBattery,
+ source: "electron",
+ };
+});
+
// allow showing a file path in os file manager
ipcMain.handle("showPathInFolder", (event, targetPath) => {
const ctx = {
diff --git a/electron/preload.js b/electron/preload.js
index 2567aeb8..eabcce43 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -83,6 +83,10 @@ contextBridge.exposeInMainWorld("electron", {
return await ipcRenderer.invoke("get-memory-usage");
},
+ getBatteryStatus: async function () {
+ return await ipcRenderer.invoke("get-battery-status");
+ },
+
// allow showing a file path in os file manager
showPathInFolder: async function (path) {
return await ipcRenderer.invoke("showPathInFolder", path);
diff --git a/meshchatx.rsm b/meshchatx.rsm
index 59f830fe..91ff3a24 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index aa0fc840..c7284e3e 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -91,6 +91,7 @@
/>
</button>
<LanguageSelector class="hidden sm:block" @language-change="onLanguageChange" />
+ <BatteryStatusChip />
<NotificationBell />
<button
type="button"
@@ -594,6 +595,7 @@ import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import QRCode from "qrcode";
import NotificationBell from "./NotificationBell.vue";
import LanguageSelector from "./LanguageSelector.vue";
+import BatteryStatusChip from "./layout/BatteryStatusChip.vue";
import CallOverlay from "./call/CallOverlay.vue";
import CommandPalette from "./CommandPalette.vue";
import IntegrityWarningModal from "./IntegrityWarningModal.vue";
@@ -623,6 +625,7 @@ export default {
MaterialDesignIcon,
NotificationBell,
LanguageSelector,
+ BatteryStatusChip,
CallOverlay,
CommandPalette,
IntegrityWarningModal,
diff --git a/meshchatx/src/frontend/components/about/AboutPage.vue b/meshchatx/src/frontend/components/about/AboutPage.vue
index 4bd067e8..109e7edf 100644
--- a/meshchatx/src/frontend/components/about/AboutPage.vue
+++ b/meshchatx/src/frontend/components/about/AboutPage.vue
@@ -459,6 +459,22 @@
}}</span>
<span class="font-mono text-xs font-bold">{{ environmentInfo.platform }}</span>
</div>
+ <div class="flex items-center justify-between gap-3">
+ <span class="text-[10px] font-black text-lime-600 uppercase tracking-wider">{{
+ $t("about.env_battery")
+ }}</span>
+ <span
+ class="font-mono text-xs font-bold shrink-0 inline-flex items-center gap-1"
+ :class="batteryStatusToneClass"
+ >
+ <v-icon
+ v-if="batteryStatus"
+ :icon="'mdi-' + batteryStatusIcon"
+ size="14"
+ ></v-icon>
+ {{ batteryStatusLabel }}
+ </span>
+ </div>
<div
v-if="isLinuxHost && appInfo.landlock_requested !== undefined"
class="flex flex-col gap-1"
@@ -1073,6 +1089,7 @@ import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import DownloadUtils from "../../js/DownloadUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
+import { batteryStatusIconName, getDeviceBatteryStatus } from "../../js/deviceBattery.js";
export default {
name: "AboutPage",
components: {},
@@ -1092,6 +1109,7 @@ export default {
databaseActionInProgress: false,
healthLoading: false,
electronMemoryUsage: null,
+ batteryStatus: null,
backupInProgress: false,
backupMessage: "",
backupError: "",
@@ -1161,6 +1179,39 @@ export default {
}
return this.$t("app.landlock_inactive");
},
+ batteryStatusIcon() {
+ return batteryStatusIconName(this.batteryStatus);
+ },
+ batteryStatusLabel() {
+ if (!this.batteryStatus || !this.batteryStatus.supported) {
+ return this.$t("about.env_battery_unavailable");
+ }
+ const level =
+ this.batteryStatus.level != null ? `${this.batteryStatus.level}%` : this.$t("about.path_unknown");
+ if (this.batteryStatus.charging === true) {
+ return this.$t("about.env_battery_charging", { percent: level });
+ }
+ if (this.batteryStatus.charging === false) {
+ return this.$t("about.env_battery_on_battery", { percent: level });
+ }
+ return level;
+ },
+ batteryStatusToneClass() {
+ if (!this.batteryStatus || !this.batteryStatus.supported) {
+ return "opacity-70";
+ }
+ if (this.batteryStatus.charging) {
+ return "text-emerald-600 dark:text-emerald-400";
+ }
+ const level = this.batteryStatus.level;
+ if (level != null && level <= 15) {
+ return "text-red-600 dark:text-red-400";
+ }
+ if (level != null && level <= 30) {
+ return "text-amber-600 dark:text-amber-400";
+ }
+ return "";
+ },
environmentInfo() {
const ua = typeof navigator !== "undefined" ? navigator.userAgent || "" : "";
let platform = typeof navigator !== "undefined" && navigator.platform ? navigator.platform : "";
@@ -1361,10 +1412,18 @@ export default {
this.chromeVersion = window.electron.chromeVersion();
this.nodeVersion = window.electron.nodeVersion();
}
+ await this.refreshBatteryStatus();
} catch (e) {
console.log(e);
}
},
+ async refreshBatteryStatus() {
+ try {
+ this.batteryStatus = await getDeviceBatteryStatus();
+ } catch {
+ this.batteryStatus = null;
+ }
+ },
async acknowledgeIntegrity() {
if (await DialogUtils.confirm(this.$t("about.integrity_acknowledge_confirm"))) {
try {
diff --git a/meshchatx/src/frontend/components/layout/BatteryStatusChip.vue b/meshchatx/src/frontend/components/layout/BatteryStatusChip.vue
new file mode 100644
index 00000000..57a60864
--- /dev/null
+++ b/meshchatx/src/frontend/components/layout/BatteryStatusChip.vue
@@ -0,0 +1,143 @@
+<template>
+ <button
+ v-if="visible"
+ type="button"
+ class="inline-flex items-center gap-1 rounded-full px-2 py-1 text-xs font-semibold tabular-nums transition-colors"
+ :class="chipClass"
+ :title="titleText"
+ :aria-label="titleText"
+ @click="onClick"
+ >
+ <MaterialDesignIcon :icon-name="iconName" class="h-4 w-4 shrink-0" />
+ <span>{{ levelLabel }}</span>
+ </button>
+</template>
+
+<script>
+// SPDX-License-Identifier: 0BSD
+
+import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import { batteryStatusIconName, getDeviceBatteryStatus, shouldShowBatteryChip } from "../../js/deviceBattery.js";
+
+const POLL_MS = 60000;
+
+export default {
+ name: "BatteryStatusChip",
+ components: {
+ MaterialDesignIcon,
+ },
+ emits: ["open-about"],
+ data() {
+ return {
+ status: null,
+ pollTimer: null,
+ webBattery: null,
+ };
+ },
+ computed: {
+ visible() {
+ return shouldShowBatteryChip(this.status);
+ },
+ iconName() {
+ return batteryStatusIconName(this.status);
+ },
+ levelLabel() {
+ if (this.status?.level == null) {
+ return "";
+ }
+ return `${this.status.level}%`;
+ },
+ titleText() {
+ if (!this.status?.supported) {
+ return this.$t("app.battery_unavailable");
+ }
+ const level = this.status.level != null ? `${this.status.level}%` : this.$t("about.path_unknown");
+ if (this.status.charging === true) {
+ return this.$t("app.battery_charging_title", { percent: level });
+ }
+ if (this.status.charging === false) {
+ return this.$t("app.battery_discharging_title", { percent: level });
+ }
+ return this.$t("app.battery_level_title", { percent: level });
+ },
+ chipClass() {
+ const level = this.status?.level;
+ if (this.status?.charging) {
+ return "text-emerald-700 dark:text-emerald-300 hover:bg-emerald-50 dark:hover:bg-emerald-950/40";
+ }
+ if (level != null && level <= 15) {
+ return "text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950/40";
+ }
+ if (level != null && level <= 30) {
+ return "text-amber-700 dark:text-amber-300 hover:bg-amber-50 dark:hover:bg-amber-950/40";
+ }
+ return "text-gray-700 dark:text-zinc-200 hover:bg-gray-100 dark:hover:bg-zinc-800";
+ },
+ },
+ mounted() {
+ this.refresh();
+ this.pollTimer = setInterval(() => {
+ this.refresh();
+ }, POLL_MS);
+ },
+ beforeUnmount() {
+ if (this.pollTimer) {
+ clearInterval(this.pollTimer);
+ this.pollTimer = null;
+ }
+ this.detachWebBatteryListeners();
+ },
+ methods: {
+ onClick() {
+ this.$emit("open-about");
+ if (this.$router) {
+ this.$router.push({ name: "about" });
+ }
+ },
+ detachWebBatteryListeners() {
+ if (!this.webBattery) {
+ return;
+ }
+ try {
+ this.webBattery.removeEventListener("levelchange", this.onWebBatteryChange);
+ this.webBattery.removeEventListener("chargingchange", this.onWebBatteryChange);
+ } catch {
+ // ignore
+ }
+ this.webBattery = null;
+ },
+ onWebBatteryChange() {
+ this.refresh();
+ },
+ async attachWebBatteryListeners() {
+ if (this.webBattery) {
+ return;
+ }
+ if (typeof navigator === "undefined" || typeof navigator.getBattery !== "function") {
+ return;
+ }
+ try {
+ const battery = await navigator.getBattery();
+ if (!battery) {
+ return;
+ }
+ this.webBattery = battery;
+ battery.addEventListener("levelchange", this.onWebBatteryChange);
+ battery.addEventListener("chargingchange", this.onWebBatteryChange);
+ } catch {
+ // Browser may deny Battery Status API.
+ }
+ },
+ async refresh() {
+ try {
+ this.status = await getDeviceBatteryStatus();
+ if (this.status?.source === "web") {
+ await this.attachWebBatteryListeners();
+ }
+ } catch {
+ this.status = null;
+ }
+ },
+ },
+};
+</script>
diff --git a/meshchatx/src/frontend/js/ElectronUtils.js b/meshchatx/src/frontend/js/ElectronUtils.js
index e9d78242..e75c868d 100644
--- a/meshchatx/src/frontend/js/ElectronUtils.js
+++ b/meshchatx/src/frontend/js/ElectronUtils.js
@@ -38,6 +38,17 @@ class ElectronUtils {
return null;
}
+ static async getBatteryStatus() {
+ if (!window.electron?.getBatteryStatus) {
+ return null;
+ }
+ try {
+ return await window.electron.getBatteryStatus();
+ } catch {
+ return null;
+ }
+ }
+
static showPathInFolder(path) {
if (window.electron) {
window.electron.showPathInFolder(path);
diff --git a/meshchatx/src/frontend/js/deviceBattery.js b/meshchatx/src/frontend/js/deviceBattery.js
new file mode 100644
index 00000000..0cba5b4a
--- /dev/null
+++ b/meshchatx/src/frontend/js/deviceBattery.js
@@ -0,0 +1,216 @@
+// SPDX-License-Identifier: 0BSD
+
+/**
+ * Host device battery probe for laptops and mobile.
+ *
+ * Order: Android bridge, Electron IPC, then Chromium Battery Status API.
+ * Returns null when the runtime cannot expose battery state.
+ */
+
+import AndroidBridge from "./rnode/AndroidBridge.js";
+import ElectronUtils from "./ElectronUtils.js";
+
+/**
+ * @typedef {object} DeviceBatteryStatus
+ * @property {boolean} supported
+ * @property {number|null} level Percent 0-100, or null when unknown
+ * @property {boolean|null} charging
+ * @property {"android"|"electron"|"web"|null} source
+ */
+
+/**
+ * @param {unknown} value
+ * @param {{ unitFraction?: boolean }} [options]
+ * @returns {number|null}
+ */
+export function normalizeBatteryPercent(value, options = {}) {
+ if (value == null || value === "") {
+ return null;
+ }
+ const n = typeof value === "number" ? value : Number(value);
+ if (!Number.isFinite(n)) {
+ return null;
+ }
+ const unitFraction = Boolean(options.unitFraction);
+ let pct;
+ if (unitFraction) {
+ // Chromium Battery Status API: 0.0-1.0
+ pct = n * 100;
+ } else if (n > 0 && n < 1) {
+ // Ambiguous float without an explicit scale: treat as a fraction.
+ pct = n * 100;
+ } else {
+ // Native bridges report whole percents (including 0, 1, and 100).
+ pct = n;
+ }
+ if (pct < 0 || pct > 100) {
+ return null;
+ }
+ return Math.round(pct);
+}
+
+/**
+ * @param {unknown} raw
+ * @param {"android"|"electron"|"web"|null} source
+ * @returns {DeviceBatteryStatus|null}
+ */
+export function normalizeBatteryStatus(raw, source = null) {
+ if (raw == null || typeof raw !== "object" || Array.isArray(raw)) {
+ return null;
+ }
+ const resolvedSource = source || raw.source || null;
+ const level = normalizeBatteryPercent(raw.level ?? raw.percent ?? raw.charge_percent ?? raw.capacity, {
+ unitFraction: resolvedSource === "web",
+ });
+ let charging = null;
+ if (typeof raw.charging === "boolean") {
+ charging = raw.charging;
+ } else if (raw.charging === 1 || raw.charging === "1" || raw.charging === "true") {
+ charging = true;
+ } else if (raw.charging === 0 || raw.charging === "0" || raw.charging === "false") {
+ charging = false;
+ } else if (typeof raw.on_battery === "boolean") {
+ charging = !raw.on_battery;
+ } else if (typeof raw.is_charging === "boolean") {
+ charging = raw.is_charging;
+ }
+ if (level == null && charging == null) {
+ return null;
+ }
+ return {
+ supported: true,
+ level,
+ charging,
+ source: resolvedSource,
+ };
+}
+
+/**
+ * @param {string|object|null|undefined} payload
+ * @returns {DeviceBatteryStatus|null}
+ */
+export function parseAndroidBatteryPayload(payload) {
+ if (payload == null || payload === "") {
+ return null;
+ }
+ let raw = payload;
+ if (typeof payload === "string") {
+ try {
+ raw = JSON.parse(payload);
+ } catch {
+ return null;
+ }
+ }
+ return normalizeBatteryStatus(raw, "android");
+}
+
+/**
+ * @returns {Promise<DeviceBatteryStatus|null>}
+ */
+async function probeAndroidBattery() {
+ try {
+ const bridge = new AndroidBridge();
+ if (!bridge.isAvailable()) {
+ return null;
+ }
+ const payload = bridge.getBatteryStatus();
+ return parseAndroidBatteryPayload(payload);
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * @returns {Promise<DeviceBatteryStatus|null>}
+ */
+async function probeElectronBattery() {
+ if (!ElectronUtils.isElectron()) {
+ return null;
+ }
+ try {
+ const raw = await ElectronUtils.getBatteryStatus();
+ return normalizeBatteryStatus(raw, "electron");
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * @returns {Promise<DeviceBatteryStatus|null>}
+ */
+async function probeWebBattery() {
+ if (typeof navigator === "undefined" || typeof navigator.getBattery !== "function") {
+ return null;
+ }
+ try {
+ const battery = await navigator.getBattery();
+ if (!battery) {
+ return null;
+ }
+ return normalizeBatteryStatus(
+ {
+ level: battery.level,
+ charging: battery.charging,
+ },
+ "web"
+ );
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Read current host battery status when the platform supports it.
+ *
+ * @returns {Promise<DeviceBatteryStatus|null>}
+ */
+export async function getDeviceBatteryStatus() {
+ const androidStatus = await probeAndroidBattery();
+ if (androidStatus) {
+ return androidStatus;
+ }
+ const electronStatus = await probeElectronBattery();
+ if (electronStatus) {
+ return electronStatus;
+ }
+ return probeWebBattery();
+}
+
+/**
+ * Material icon name for a battery reading.
+ *
+ * @param {DeviceBatteryStatus|null|undefined} status
+ * @returns {string}
+ */
+export function batteryStatusIconName(status) {
+ if (!status || !status.supported) {
+ return "battery-unknown";
+ }
+ if (status.charging) {
+ return "battery-charging";
+ }
+ const level = status.level;
+ if (level == null) {
+ return "battery";
+ }
+ if (level <= 15) {
+ return "battery-alert";
+ }
+ if (level <= 30) {
+ return "battery-low";
+ }
+ if (level >= 90) {
+ return "battery";
+ }
+ return "battery-medium";
+}
+
+/**
+ * Whether the header chip should be visible.
+ *
+ * @param {DeviceBatteryStatus|null|undefined} status
+ * @returns {boolean}
+ */
+export function shouldShowBatteryChip(status) {
+ return Boolean(status && status.supported && status.level != null);
+}
diff --git a/meshchatx/src/frontend/js/rnode/AndroidBridge.js b/meshchatx/src/frontend/js/rnode/AndroidBridge.js
index ca370e0c..99f4be92 100644
--- a/meshchatx/src/frontend/js/rnode/AndroidBridge.js
+++ b/meshchatx/src/frontend/js/rnode/AndroidBridge.js
@@ -112,6 +112,17 @@ export default class AndroidBridge {
return safeCall(() => this.bridge.getPlatform(), null);
}
+ /**
+ * Host battery status JSON string from the Android WebView bridge.
+ * Returns null when unavailable.
+ */
+ getBatteryStatus() {
+ if (!this.bridge || typeof this.bridge.getBatteryStatus !== "function") {
+ return null;
+ }
+ return safeCall(() => this.bridge.getBatteryStatus(), null);
+ }
+
getSidebandPluginsDefaultPath() {
if (!this.bridge || typeof this.bridge.getSidebandPluginsDefaultPath !== "function") {
return null;
diff --git a/meshchatx/src/frontend/locales/de.json b/meshchatx/src/frontend/locales/de.json
index 538a78cc..662b000e 100644
--- a/meshchatx/src/frontend/locales/de.json
+++ b/meshchatx/src/frontend/locales/de.json
@@ -486,7 +486,11 @@
"recover_network": "Netzwerk erneut versuchen",
"open_interfaces": "Schnittstellen öffnen",
"network_recovered": "Netzwerkstapel wiederhergestellt",
- "network_recover_failed": "Netzwerkstapel konnte nicht wiederhergestellt werden. Prüfen Sie die Schnittstellen und versuchen Sie es erneut."
+ "network_recover_failed": "Netzwerkstapel konnte nicht wiederhergestellt werden. Prüfen Sie die Schnittstellen und versuchen Sie es erneut.",
+ "battery_unavailable": "Akkustatus nicht verfügbar",
+ "battery_level_title": "Akku {percent}",
+ "battery_charging_title": "Lädt {percent}",
+ "battery_discharging_title": "Akku {percent}"
},
"common": {
"open": "Öffnen",
@@ -1181,7 +1185,11 @@
"tagline_after": ".",
"tagline_lead": "Eine sichere, widerstandsfähige Kommunikationsplattform auf Basis des ",
"tagline_link": "Reticulum Network Stack",
- "technical_issues_detected": "Änderungen seit dem letzten Snapshot"
+ "technical_issues_detected": "Änderungen seit dem letzten Snapshot",
+ "env_battery": "Akku",
+ "env_battery_unavailable": "Auf diesem Gerät nicht verfügbar",
+ "env_battery_charging": "Lädt {percent}",
+ "env_battery_on_battery": "Akku {percent}"
},
"interfaces": {
"title": "Schnittstellen",
diff --git a/meshchatx/src/frontend/locales/en.json b/meshchatx/src/frontend/locales/en.json
index 29f39ba7..659f79d3 100644
--- a/meshchatx/src/frontend/locales/en.json
+++ b/meshchatx/src/frontend/locales/en.json
@@ -486,7 +486,11 @@
"map_tile_server_url": "Tile server URL",
"map_nominatim_api_url": "Nominatim API URL",
"map_offline_enabled": "Offline MBTiles enabled",
- "map_tile_cache_enabled": "Tile cache enabled"
+ "map_tile_cache_enabled": "Tile cache enabled",
+ "battery_unavailable": "Battery status unavailable",
+ "battery_level_title": "Battery {percent}",
+ "battery_charging_title": "Charging {percent}",
+ "battery_discharging_title": "On battery {percent}"
},
"common": {
"open": "Open",
@@ -1129,7 +1133,11 @@
"failed_acknowledge_integrity": "Failed to acknowledge integrity issues",
"shutdown_sent": "Shutdown command sent to server.",
"identity_exported": "Identity key file exported",
- "identity_copied": "Identity Base32 key copied to clipboard"
+ "identity_copied": "Identity Base32 key copied to clipboard",
+ "env_battery": "Battery",
+ "env_battery_unavailable": "Unavailable on this device",
+ "env_battery_charging": "Charging {percent}",
+ "env_battery_on_battery": "On battery {percent}"
},
"interfaces": {
"title": "Interfaces",
diff --git a/meshchatx/src/frontend/locales/es.json b/meshchatx/src/frontend/locales/es.json
index c7301b69..7b4ee7b7 100644
--- a/meshchatx/src/frontend/locales/es.json
+++ b/meshchatx/src/frontend/locales/es.json
@@ -486,7 +486,11 @@
"recover_network": "Reintentar red",
"open_interfaces": "Abrir interfaces",
"network_recovered": "Pila de red recuperada",
- "network_recover_failed": "No se pudo recuperar la pila de red. Revise las interfaces e inténtelo de nuevo."
+ "network_recover_failed": "No se pudo recuperar la pila de red. Revise las interfaces e inténtelo de nuevo.",
+ "battery_unavailable": "Estado de batería no disponible",
+ "battery_level_title": "Batería {percent}",
+ "battery_charging_title": "Cargando {percent}",
+ "battery_discharging_title": "En batería {percent}"
},
"common": {
"open": "Abierto",
@@ -1129,7 +1133,11 @@
"tagline_after": ".",
"tagline_lead": "Una plataforma de comunicaciones segura y resistente basada en ",
"tagline_link": "Reticulum Network Stack",
- "technical_issues_detected": "Cambios desde la última instantánea"
+ "technical_issues_detected": "Cambios desde la última instantánea",
+ "env_battery": "Batería",
+ "env_battery_unavailable": "No disponible en este dispositivo",
+ "env_battery_charging": "Cargando {percent}",
+ "env_battery_on_battery": "En batería {percent}"
},
"interfaces": {
"title": "Interfaces",
diff --git a/meshchatx/src/frontend/locales/fi.json b/meshchatx/src/frontend/locales/fi.json
index 7a92febe..68d7d66a 100644
--- a/meshchatx/src/frontend/locales/fi.json
+++ b/meshchatx/src/frontend/locales/fi.json
@@ -486,7 +486,11 @@
"recover_network": "Yritä verkkoa uudelleen",
"open_interfaces": "Avaa liittymät",
"network_recovered": "Verkkopinon palautus onnistui",
- "network_recover_failed": "Verkkopinoa ei voitu palauttaa. Tarkista liittymät ja yritä uudelleen."
+ "network_recover_failed": "Verkkopinoa ei voitu palauttaa. Tarkista liittymät ja yritä uudelleen.",
+ "battery_unavailable": "Akun tila ei ole saatavilla",
+ "battery_level_title": "Akku {percent}",
+ "battery_charging_title": "Lataa {percent}",
+ "battery_discharging_title": "Akulla {percent}"
},
"common": {
"open": "Avaa",
@@ -1129,7 +1133,11 @@
"failed_acknowledge_integrity": "Eheysongelmien kuittaus epäonnistui",
"shutdown_sent": "Sammutuskäsky lähetetty palvelimelle.",
"identity_exported": "Identiteetien avaintiedosto viety",
- "identity_copied": "Identiteetin Base32-avain kopioitu leikepöydälle"
+ "identity_copied": "Identiteetin Base32-avain kopioitu leikepöydälle",
+ "env_battery": "Akku",
+ "env_battery_unavailable": "Ei saatavilla tällä laitteella",
+ "env_battery_charging": "Lataa {percent}",
+ "env_battery_on_battery": "Akulla {percent}"
},
"interfaces": {
"title": "Sovittimet",
diff --git a/meshchatx/src/frontend/locales/fr.json b/meshchatx/src/frontend/locales/fr.json
index cf9c23f4..9c4aee77 100644
--- a/meshchatx/src/frontend/locales/fr.json
+++ b/meshchatx/src/frontend/locales/fr.json
@@ -486,7 +486,11 @@
"recover_network": "Réessayer le réseau",
"open_interfaces": "Ouvrir les interfaces",
"network_recovered": "Pile réseau rétablie",
- "network_recover_failed": "Impossible de rétablir la pile réseau. Vérifiez les interfaces et réessayez."
+ "network_recover_failed": "Impossible de rétablir la pile réseau. Vérifiez les interfaces et réessayez.",
+ "battery_unavailable": "État de la batterie indisponible",
+ "battery_level_title": "Batterie {percent}",
+ "battery_charging_title": "En charge {percent}",
+ "battery_discharging_title": "Sur batterie {percent}"
},
"common": {
"open": "Ouvrir",
@@ -1129,7 +1133,11 @@
"tagline_after": ".",
"tagline_lead": "Une plateforme de communication sécurisée et résiliente basée sur ",
"tagline_link": "Reticulum Network Stack",
- "technical_issues_detected": "Changements depuis le dernier instantané"
+ "technical_issues_detected": "Changements depuis le dernier instantané",
+ "env_battery": "Batterie",
+ "env_battery_unavailable": "Indisponible sur cet appareil",
+ "env_battery_charging": "En charge {percent}",
+ "env_battery_on_battery": "Sur batterie {percent}"
},
"interfaces": {
"title": "Interfaces",
diff --git a/meshchatx/src/frontend/locales/it.json b/meshchatx/src/frontend/locales/it.json
index 90268cda..80f958d3 100644
--- a/meshchatx/src/frontend/locales/it.json
+++ b/meshchatx/src/frontend/locales/it.json
@@ -486,7 +486,11 @@
"recover_network": "Riprova rete",
"open_interfaces": "Apri interfacce",
"network_recovered": "Stack di rete ripristinato",
- "network_recover_failed": "Impossibile ripristinare lo stack di rete. Controlla le interfacce e riprova."
+ "network_recover_failed": "Impossibile ripristinare lo stack di rete. Controlla le interfacce e riprova.",
+ "battery_unavailable": "Stato batteria non disponibile",
+ "battery_level_title": "Batteria {percent}",
+ "battery_charging_title": "In carica {percent}",
+ "battery_discharging_title": "A batteria {percent}"
},
"common": {
"open": "Apri",
@@ -1181,7 +1185,11 @@
"tagline_after": ".",
"tagline_lead": "Piattaforma di comunicazione sicura e resiliente basata su ",
"tagline_link": "Reticulum Network Stack",
- "technical_issues_detected": "Modifiche dall'ultima istantanea"
+ "technical_issues_detected": "Modifiche dall'ultima istantanea",
+ "env_battery": "Batteria",
+ "env_battery_unavailable": "Non disponibile su questo dispositivo",
+ "env_battery_charging": "In carica {percent}",
+ "env_battery_on_battery": "A batteria {percent}"
},
"interfaces": {
"title": "Interfacce",
diff --git a/meshchatx/src/frontend/locales/nl.json b/meshchatx/src/frontend/locales/nl.json
index 539d6857..58c8161f 100644
--- a/meshchatx/src/frontend/locales/nl.json
+++ b/meshchatx/src/frontend/locales/nl.json
@@ -486,7 +486,11 @@
"recover_network": "Netwerk opnieuw proberen",
"open_interfaces": "Interfaces openen",
"network_recovered": "Netwerkstack hersteld",
- "network_recover_failed": "Netwerkstack kon niet worden hersteld. Controleer interfaces en probeer opnieuw."
+ "network_recover_failed": "Netwerkstack kon niet worden hersteld. Controleer interfaces en probeer opnieuw.",
+ "battery_unavailable": "Batterijstatus niet beschikbaar",
+ "battery_level_title": "Batterij {percent}",
+ "battery_charging_title": "Opladen {percent}",
+ "battery_discharging_title": "Op batterij {percent}"
},
"common": {
"open": "Openen",
@@ -1129,7 +1133,11 @@
"tagline_after": ".",
"tagline_lead": "Een veilige, veerkrachtige communicatieplatform gebaseerd op ",
"tagline_link": "Reticulum Network Stack",
- "technical_issues_detected": "Wijzigingen sinds laatste snapshot"
+ "technical_issues_detected": "Wijzigingen sinds laatste snapshot",
+ "env_battery": "Batterij",
+ "env_battery_unavailable": "Niet beschikbaar op dit apparaat",
+ "env_battery_charging": "Opladen {percent}",
+ "env_battery_on_battery": "Op batterij {percent}"
},
"interfaces": {
"title": "Interfaces",
diff --git a/meshchatx/src/frontend/locales/ru.json b/meshchatx/src/frontend/locales/ru.json
index 2d7c2af6..9ef00d37 100644
--- a/meshchatx/src/frontend/locales/ru.json
+++ b/meshchatx/src/frontend/locales/ru.json
@@ -486,7 +486,11 @@
"recover_network": "Повторить сеть",
"open_interfaces": "Открыть интерфейсы",
"network_recovered": "Сетевой стек восстановлен",
- "network_recover_failed": "Не удалось восстановить сетевой стек. Проверьте интерфейсы и попробуйте снова."
+ "network_recover_failed": "Не удалось восстановить сетевой стек. Проверьте интерфейсы и попробуйте снова.",
+ "battery_unavailable": "Состояние батареи недоступно",
+ "battery_level_title": "Батарея {percent}",
+ "battery_charging_title": "Зарядка {percent}",
+ "battery_discharging_title": "От батареи {percent}"
},
"common": {
"open": "Открыть",
@@ -1181,7 +1185,11 @@
"tagline_after": ".",
"tagline_lead": "Надёжная отказоустойчивая платформа связи на базе ",
"tagline_link": "Reticulum Network Stack",
- "technical_issues_detected": "Изменения с последнего снимка"
+ "technical_issues_detected": "Изменения с последнего снимка",
+ "env_battery": "Батарея",
+ "env_battery_unavailable": "Недоступно на этом устройстве",
+ "env_battery_charging": "Зарядка {percent}",
+ "env_battery_on_battery": "От батареи {percent}"
},
"interfaces": {
"title": "Интерфейсы",
diff --git a/meshchatx/src/frontend/locales/zh.json b/meshchatx/src/frontend/locales/zh.json
index 15ec251f..0f7eee7f 100644
--- a/meshchatx/src/frontend/locales/zh.json
+++ b/meshchatx/src/frontend/locales/zh.json
@@ -486,7 +486,11 @@
"recover_network": "重试网络",
"open_interfaces": "打开接口",
"network_recovered": "网络栈已恢复",
- "network_recover_failed": "无法恢复网络栈。请检查接口后重试。"
+ "network_recover_failed": "无法恢复网络栈。请检查接口后重试。",
+ "battery_unavailable": "无法获取电池状态",
+ "battery_level_title": "电池 {percent}",
+ "battery_charging_title": "充电中 {percent}",
+ "battery_discharging_title": "使用电池 {percent}"
},
"common": {
"open": "打开",
@@ -1129,7 +1133,11 @@
"tagline_after": "。",
"tagline_lead": "安全、有韧性的通信平台,基于 ",
"tagline_link": "Reticulum Network Stack",
- "technical_issues_detected": "自上次快照以来的更改"
+ "technical_issues_detected": "自上次快照以来的更改",
+ "env_battery": "电池",
+ "env_battery_unavailable": "此设备不可用",
+ "env_battery_charging": "充电中 {percent}",
+ "env_battery_on_battery": "使用电池 {percent}"
},
"interfaces": {
"title": "接口",
diff --git a/tests/frontend/AboutPage.test.js b/tests/frontend/AboutPage.test.js
index 2e516398..f1bd1f8a 100644
--- a/tests/frontend/AboutPage.test.js
+++ b/tests/frontend/AboutPage.test.js
@@ -517,4 +517,30 @@ describe("AboutPage.vue", () => {
expect(wrapper.text()).not.toContain("app.landlock_status");
});
+
+ it("loads and shows host battery status in environment info", async () => {
+ navigator.getBattery = vi.fn(async () => ({ level: 0.81, charging: true }));
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/app/info") {
+ return Promise.resolve({
+ data: { app_info: { version: "1.0.0", host_platform: "linux" } },
+ });
+ }
+ if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
+ if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
+ if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
+ return Promise.reject(new Error("Not found"));
+ });
+
+ const wrapper = mountAboutPage();
+ await vi.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.refreshBatteryStatus();
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.text()).toContain("about.env_battery");
+ expect(wrapper.vm.batteryStatus?.level).toBe(81);
+ expect(wrapper.vm.batteryStatusLabel).toContain("81%");
+ });
});
diff --git a/tests/frontend/BatteryStatusChip.test.js b/tests/frontend/BatteryStatusChip.test.js
new file mode 100644
index 00000000..b83be83c
--- /dev/null
+++ b/tests/frontend/BatteryStatusChip.test.js
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: 0BSD
+
+import { mount, flushPromises } from "@vue/test-utils";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import BatteryStatusChip from "@/components/layout/BatteryStatusChip.vue";
+import * as deviceBattery from "@/js/deviceBattery.js";
+
+vi.mock("@/js/deviceBattery.js", async () => {
+ const actual = await vi.importActual("@/js/deviceBattery.js");
+ return {
+ ...actual,
+ getDeviceBatteryStatus: vi.fn(),
+ };
+});
+
+describe("BatteryStatusChip.vue", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ const mountChip = () =>
+ mount(BatteryStatusChip, {
+ global: {
+ mocks: {
+ $t: (key, params) => (params ? `${key}:${JSON.stringify(params)}` : key),
+ $router: { push: vi.fn() },
+ },
+ stubs: {
+ MaterialDesignIcon: true,
+ },
+ },
+ });
+
+ it("hides when battery status is unavailable", async () => {
+ deviceBattery.getDeviceBatteryStatus.mockResolvedValue(null);
+ const wrapper = mountChip();
+ await flushPromises();
+ expect(wrapper.find("button").exists()).toBe(false);
+ });
+
+ it("hides when probe throws", async () => {
+ deviceBattery.getDeviceBatteryStatus.mockRejectedValue(new Error("boom"));
+ const wrapper = mountChip();
+ await flushPromises();
+ expect(wrapper.find("button").exists()).toBe(false);
+ expect(wrapper.vm.status).toBe(null);
+ });
+
+ it("renders level and navigates to about on click", async () => {
+ deviceBattery.getDeviceBatteryStatus.mockResolvedValue({
+ supported: true,
+ level: 42,
+ charging: false,
+ source: "web",
+ });
+ const wrapper = mountChip();
+ await flushPromises();
+ const button = wrapper.find("button");
+ expect(button.exists()).toBe(true);
+ expect(button.text()).toContain("42%");
+ await button.trigger("click");
+ expect(wrapper.vm.$router.push).toHaveBeenCalledWith({ name: "about" });
+ });
+
+ it("hides after a later refresh loses battery support", async () => {
+ deviceBattery.getDeviceBatteryStatus
+ .mockResolvedValueOnce({
+ supported: true,
+ level: 20,
+ charging: false,
+ source: "electron",
+ })
+ .mockResolvedValueOnce(null);
+ const wrapper = mountChip();
+ await flushPromises();
+ expect(wrapper.find("button").exists()).toBe(true);
+
+ vi.advanceTimersByTime(60000);
+ await flushPromises();
+ expect(wrapper.find("button").exists()).toBe(false);
+ });
+
+ it("shows low-battery styling under 15 percent", async () => {
+ deviceBattery.getDeviceBatteryStatus.mockResolvedValue({
+ supported: true,
+ level: 8,
+ charging: false,
+ source: "android",
+ });
+ const wrapper = mountChip();
+ await flushPromises();
+ expect(wrapper.find("button").classes().join(" ")).toContain("text-red-700");
+ });
+});
diff --git a/tests/frontend/RNodeAndroidBridge.test.js b/tests/frontend/RNodeAndroidBridge.test.js
index 3dd5b4ae..c83d1c51 100644
--- a/tests/frontend/RNodeAndroidBridge.test.js
+++ b/tests/frontend/RNodeAndroidBridge.test.js
@@ -71,6 +71,20 @@ describe("AndroidBridge", () => {
expect(ab.getPlatform()).toBe("android");
});
+ it("getBatteryStatus delegates to the bridge", () => {
+ const bridge = {
+ getBatteryStatus: vi.fn().mockReturnValue('{"level":55,"charging":true}'),
+ };
+ const ab = new AndroidBridge(bridge, {});
+ expect(ab.getBatteryStatus()).toBe('{"level":55,"charging":true}');
+ expect(bridge.getBatteryStatus).toHaveBeenCalled();
+ });
+
+ it("getBatteryStatus returns null when method missing", () => {
+ const ab = new AndroidBridge({}, {});
+ expect(ab.getBatteryStatus()).toBe(null);
+ });
+
it("auto-detects bridge from env.MeshChatXAndroid", () => {
const env = { MeshChatXAndroid: { hasBluetoothPermissions: () => true } };
const ab = new AndroidBridge(null, env);
diff --git a/tests/frontend/deviceBattery.test.js b/tests/frontend/deviceBattery.test.js
new file mode 100644
index 00000000..6c435642
--- /dev/null
+++ b/tests/frontend/deviceBattery.test.js
@@ -0,0 +1,212 @@
+// SPDX-License-Identifier: 0BSD
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import {
+ batteryStatusIconName,
+ getDeviceBatteryStatus,
+ normalizeBatteryPercent,
+ normalizeBatteryStatus,
+ parseAndroidBatteryPayload,
+ shouldShowBatteryChip,
+} from "@/js/deviceBattery.js";
+
+describe("deviceBattery", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ delete window.electron;
+ delete window.MeshChatXAndroid;
+ if (Object.prototype.hasOwnProperty.call(navigator, "getBattery")) {
+ try {
+ delete navigator.getBattery;
+ } catch {
+ navigator.getBattery = undefined;
+ }
+ }
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("normalizeBatteryPercent edge cases", () => {
+ it("accepts 0 and 100 as native percents", () => {
+ expect(normalizeBatteryPercent(0)).toBe(0);
+ expect(normalizeBatteryPercent(100)).toBe(100);
+ });
+
+ it("keeps native 1% as 1, not 100", () => {
+ expect(normalizeBatteryPercent(1)).toBe(1);
+ expect(normalizeBatteryPercent(1, { unitFraction: false })).toBe(1);
+ });
+
+ it("scales web unit fractions including 0 and 1", () => {
+ expect(normalizeBatteryPercent(0, { unitFraction: true })).toBe(0);
+ expect(normalizeBatteryPercent(1, { unitFraction: true })).toBe(100);
+ expect(normalizeBatteryPercent(0.42, { unitFraction: true })).toBe(42);
+ });
+
+ it("rejects NaN Infinity strings and out of range", () => {
+ expect(normalizeBatteryPercent(Number.NaN)).toBe(null);
+ expect(normalizeBatteryPercent(Number.POSITIVE_INFINITY)).toBe(null);
+ expect(normalizeBatteryPercent("nope")).toBe(null);
+ expect(normalizeBatteryPercent(-1)).toBe(null);
+ expect(normalizeBatteryPercent(101)).toBe(null);
+ expect(normalizeBatteryPercent(undefined)).toBe(null);
+ });
+
+ it("treats ambiguous mid floats as fractions", () => {
+ expect(normalizeBatteryPercent(0.5)).toBe(50);
+ });
+ });
+
+ describe("normalizeBatteryStatus edge cases", () => {
+ it("rejects arrays and non-objects", () => {
+ expect(normalizeBatteryStatus([])).toBe(null);
+ expect(normalizeBatteryStatus("x")).toBe(null);
+ expect(normalizeBatteryStatus(null)).toBe(null);
+ });
+
+ it("accepts charging-only electron payloads without level", () => {
+ expect(normalizeBatteryStatus({ on_battery: true }, "electron")).toEqual({
+ supported: true,
+ level: null,
+ charging: false,
+ source: "electron",
+ });
+ });
+
+ it("parses string and numeric charging flags", () => {
+ expect(normalizeBatteryStatus({ level: 10, charging: "true" }).charging).toBe(true);
+ expect(normalizeBatteryStatus({ level: 10, charging: "0" }).charging).toBe(false);
+ expect(normalizeBatteryStatus({ level: 10, charging: 1 }).charging).toBe(true);
+ expect(normalizeBatteryStatus({ level: 10, is_charging: false }).charging).toBe(false);
+ });
+
+ it("uses web scale when source is web so level 1 means 100%", () => {
+ expect(normalizeBatteryStatus({ level: 1, charging: true }, "web").level).toBe(100);
+ expect(normalizeBatteryStatus({ level: 1, charging: false }, "android").level).toBe(1);
+ });
+
+ it("returns null when both level and charging are missing", () => {
+ expect(normalizeBatteryStatus({ source: "web" })).toBe(null);
+ expect(normalizeBatteryStatus({ level: "bad" })).toBe(null);
+ });
+ });
+
+ describe("parseAndroidBatteryPayload edge cases", () => {
+ it("handles object payloads and empty failures", () => {
+ expect(parseAndroidBatteryPayload({ level: 9, charging: true })).toEqual({
+ supported: true,
+ level: 9,
+ charging: true,
+ source: "android",
+ });
+ expect(parseAndroidBatteryPayload("")).toBe(null);
+ expect(parseAndroidBatteryPayload(null)).toBe(null);
+ expect(parseAndroidBatteryPayload("not-json")).toBe(null);
+ expect(parseAndroidBatteryPayload("[]")).toBe(null);
+ expect(parseAndroidBatteryPayload('{"level":999}')).toBe(null);
+ });
+ });
+
+ it("picks icon names across thresholds", () => {
+ expect(batteryStatusIconName(null)).toBe("battery-unknown");
+ expect(batteryStatusIconName({ supported: false })).toBe("battery-unknown");
+ expect(batteryStatusIconName({ supported: true, charging: true, level: 40 })).toBe("battery-charging");
+ expect(batteryStatusIconName({ supported: true, charging: false, level: null })).toBe("battery");
+ expect(batteryStatusIconName({ supported: true, charging: false, level: 0 })).toBe("battery-alert");
+ expect(batteryStatusIconName({ supported: true, charging: false, level: 15 })).toBe("battery-alert");
+ expect(batteryStatusIconName({ supported: true, charging: false, level: 30 })).toBe("battery-low");
+ expect(batteryStatusIconName({ supported: true, charging: false, level: 90 })).toBe("battery");
+ expect(batteryStatusIconName({ supported: true, charging: false, level: 55 })).toBe("battery-medium");
+ });
+
+ it("shows chip only when level is known", () => {
+ expect(shouldShowBatteryChip(null)).toBe(false);
+ expect(shouldShowBatteryChip({ supported: true, level: null })).toBe(false);
+ expect(shouldShowBatteryChip({ supported: false, level: 50 })).toBe(false);
+ expect(shouldShowBatteryChip({ supported: true, level: 0 })).toBe(true);
+ expect(shouldShowBatteryChip({ supported: true, level: 55 })).toBe(true);
+ });
+
+ describe("getDeviceBatteryStatus probe order and failures", () => {
+ it("prefers android bridge over web battery", async () => {
+ window.MeshChatXAndroid = {
+ getBatteryStatus: () => '{"level":77,"charging":true}',
+ };
+ navigator.getBattery = vi.fn(async () => ({ level: 0.1, charging: false }));
+ const status = await getDeviceBatteryStatus();
+ expect(status).toEqual({
+ supported: true,
+ level: 77,
+ charging: true,
+ source: "android",
+ });
+ expect(navigator.getBattery).not.toHaveBeenCalled();
+ });
+
+ it("falls back when android returns empty or throws", async () => {
+ window.MeshChatXAndroid = {
+ getBatteryStatus: () => {
+ throw new Error("bridge boom");
+ },
+ };
+ navigator.getBattery = vi.fn(async () => ({ level: 0.33, charging: false }));
+ await expect(getDeviceBatteryStatus()).resolves.toEqual({
+ supported: true,
+ level: 33,
+ charging: false,
+ source: "web",
+ });
+
+ window.MeshChatXAndroid = { getBatteryStatus: () => "" };
+ await expect(getDeviceBatteryStatus()).resolves.toEqual({
+ supported: true,
+ level: 33,
+ charging: false,
+ source: "web",
+ });
+ });
+
+ it("uses electron before web and survives electron rejection", async () => {
+ window.electron = {
+ getBatteryStatus: vi.fn().mockResolvedValue({
+ level: 12,
+ charging: false,
+ on_battery: true,
+ }),
+ };
+ navigator.getBattery = vi.fn(async () => ({ level: 0.9, charging: true }));
+ await expect(getDeviceBatteryStatus()).resolves.toEqual({
+ supported: true,
+ level: 12,
+ charging: false,
+ source: "electron",
+ });
+ expect(navigator.getBattery).not.toHaveBeenCalled();
+
+ window.electron.getBatteryStatus = vi.fn().mockRejectedValue(new Error("ipc fail"));
+ await expect(getDeviceBatteryStatus()).resolves.toEqual({
+ supported: true,
+ level: 90,
+ charging: true,
+ source: "web",
+ });
+ });
+
+ it("returns null when web getBattery rejects or returns empty", async () => {
+ navigator.getBattery = vi.fn(async () => {
+ throw new Error("denied");
+ });
+ await expect(getDeviceBatteryStatus()).resolves.toBe(null);
+
+ navigator.getBattery = vi.fn(async () => null);
+ await expect(getDeviceBatteryStatus()).resolves.toBe(null);
+ });
+
+ it("returns null when no probe is available", async () => {
+ const status = await getDeviceBatteryStatus();
+ expect(status).toBe(null);
+ });
+ });
+});
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────